并发编程—CAS(Compare And Swap)

案例

在讨论CAS前,先看并发编程—Volatile关键字文章中写过的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class Counter {

private volatile static int count = 0;

private static final int THREADS_COUNT = 10;

private static CountDownLatch countDownLatch = new CountDownLatch(THREADS_COUNT);

private static void increase() {
count++;
}

public static void main(String[] args) throws InterruptedException {
//同时启动10个线程,每个线程进行1万次i++计算
for (int i = 0; i < THREADS_COUNT; i++) {
new Thread(() -> {
for (int j = 0; j < 10000; j++) {
increase();
}
countDownLatch.countDown();
}).start();
}

countDownLatch.await();
System.out.println("运行结果:Counter.count=" + Counter.count); //结果会<100000
}



}

运行完这段代码之后,并不会获得期望的结果,而且会发现每次运行程序,输出的结果都不一样,会得到一个小于100000的数字。

原因

volatile关键字只能保证变量的可见性,并不能保证原子性,而自增操作并不是一个原子操作,它包含了读取-修改-写回的过程。因为有volatile修饰该变量,修改和写回可以当作原子操作,但却可能发生线程1读取完,还没有写回之前,线程2又读取了的情况,最终两个线程都进行了自增操作,但变量值只增加了1。

解决方案

首先想到的方案是使用synchronized关键字对increase()方法加锁,synchronized就是一种独占锁,它能够确保程序执行时不会被其它线程干扰,得到正确的结果。

但是,synchronized效率比较低(会需要加锁、解锁、并且有锁竞争),并且等待锁的线程会阻塞,高并发的情况下不是一个好的解决方案。

synchronized实质上是一种悲观锁,它始终认为别的线程会去修改值。另一种是乐观锁的方案:认为别的线程不会去修改值;如果发现值被修改了,可以再次重试。这就是本文所说的CAS方式,CAS机制(Compare And Swap)本质上就是一种乐观锁。

Compare And Swap

CAS是一种有名的无锁(lock-free)算法。也是一种现代 CPU 广泛支持的CPU指令级的操作,只有一步原子操作,所以非常快。而且CAS避免了请求操作系统来裁定锁的问题,不用麻烦操作系统,直接在CPU内部就搞定了。

CAS有三个操作参数:

  1. 内存地址V(它的值是我们想要去更新的)
  2. 预期原值A(上一次从内存中读取的值)
  3. 新值B(应该写入的新值)

CAS的操作过程:将内存地址V的值与A比较(compare),如果相等,说明没有其它线程来修改过这个值,所以直接把内存地址V的值更新成B(swap),如果不相等,说明V上的值被修改过了,所以当前线程先不更新,而是返回当前V的值。

所以,当多个线程尝试使用CAS同时更新同一个变量时,其中一个线程会成功更新变量的值,剩下的会失败。失败的线程可以重试或者什么也不做。

简单来说,CAS 的含义是“我认为原有的值应该是什么,如果是,则将原有的值更新为新值,否则不做修改,并告诉我这个值现在是多少”。(这段描述引自《Java并发编程实践》)

JVM对CAS的支持

在JDK1.5之前,如果不编写明确的代码就无法执行CAS操作,在JDK1.5中引入了底层的支持,在int、long和对象的引用等类型上都公开了CAS的操作,并且JVM把它们编译为底层硬件提供的最有效的方法,在运行CAS的平台上,运行时把它们编译为相应的机器指令,如果处理器/CPU不支持CAS指令,那么JVM将使用自旋锁。

在原子类变量中,如java.util.concurrent.atomic包下的AtomicXXX,都使用了这些底层的JVM支持为数字类型的引用类型提供一种高效的CAS操作,而在java.util.concurrent中的大多数类在实现时都直接或间接的使用了这些原子变量类。

java.util.concurrent.atomic.AtomicLong源码中的自增getAndIncrement()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//+1操作
public final long getAndIncrement() {
while (true) {
long current = get();
long next = current + 1;
//当+1操作成功的时候直接返回,退出此循环
if (compareAndSet(current, next))
return current;
}
}


//调用JNI实现CAS
public final boolean compareAndSet(long expect, long update) {
return unsafe.compareAndSwapLong(this, valueOffset, expect, update);
}

JNI:Java Native Interface为JAVA本地调用,允许java调用其他语言。在jdk1.8后getAndIncrement()方法已经看不到具体代码了,而是封装在Unsafe类里面。
UnSafe类使Java拥有了像C语言的指针一样操作内存空间的能力,同时也带来了指针的问题,过度的使用Unsafe类会使得出错的几率变大,因此Java官方并不建议开发人员使用Unsafe类,官方文档也几乎没有,Unsafe类不能直接调用,只能通过反射获取。

CAS获取共享变量时,为了保证变量的可见性,需要使用volatile修饰,它们两者结合可以实现无锁并发,适用于竞争不激烈,多核CPU场景下。

CAS缺点

CAS虽然很高效的解决原子操作,但是CAS仍然存在三大问题。ABA问题,循环时间长开销大和只能保证一个共享变量的原子操作。

  1. ABA问题。因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。

    从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

  1. 循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销,所以线程竞争比较大的情况下不适合用CAS。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
  1. 只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。

  2. 比较花费CPU资源,即使没有任何争用也会做一些无用功。

CAS与MESI的关系

在x86架构上,CAS被翻译为”lock cmpxchg…“,当两个core同时执行针对同一地址的CAS指令时,其实他们是在试图修改每个core自己持有的cache line。

假设两个core都持有相同地址对应cache line,且各自cache line 状态为S, 这时如果要想成功修改,就首先需要把S转为E或者M, 则需要向其它core invalidate 这个地址的cacheline。两个core都会向ring bus发出 invalidate 操作, ringbus会根据特定的设计协议仲裁是core0还是core1能赢得这个invalidate, 胜者完成操作, 失败者需要接受结果, 即invalidate自己持有的cache line,再读取胜者修改后的值, 回到起点。

对于我们的CAS操作来说, 其实锁并没有消失,只是转嫁到了ring bus的总线仲裁协议中. 而且大量的多核同时针对一个地址的CAS操作会引起反复的互相invalidate 同一cacheline, 造成pingpong效应, 同样会降低性能。当然如果真的有性能问题,我觉得这可能会在ns级别体现了,一般的应用程序中使用CAS应该不会引起性能问题。

总结

可以用CAS在无锁的情况下实现原子操作,但要明确应用场合,非常简单的操作且又不想引入锁可以考虑使用CAS操作,当想要非阻塞地完成某一操作也可以考虑CAS。不推荐在复杂操作中引入CAS,会使程序可读性变差,且难以测试,同时会出现ABA问题。

------ 本文完 ------